iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 25 篇

【 Day 24 】誰被觀察?量到的尺寸又怎麼記?|真實 DOM(二)

  • 分享至 

  • xImage
  •  

昨天,我們讓 createFixedList 在瀏覽器渲染之前就替每一列算好位置。不過,第 3 列一折行,兩列就重疊了。它從瀏覽器拿到的只有 scrollTop,第 3 列實際多高,它沒有地方收下。

所以要量。可是量測要等元素畫出來才量得到,量到之後,又會改變下一次要畫什麼。

要接起這個循環,至少得先回答兩件事:要請瀏覽器看著誰?量到的數字,又怎麼記?

讓我們先從比較直覺的作法開始:哪一列要量,就看著哪一列。

每一列各自被看著

還沒畫之前,我們仍然不知道一列多高,只能先估一個數字。這裡估 40px,比實際的 50px 少一點。

量到之後,把結果記進一個 Map,以列的索引當 key;沒量過的列,才用估計值。這個 Map 從外面傳進來,好讓我們等一下印出它記了幾筆。

清單不必知道這個 Map 的所有用法,只需要兩件事:get 讀出某一列記下的尺寸,set 寫入量到的尺寸。另外留著 size,只是為了等一下印出記了幾筆。我們先把它們寫成型別:

// measured-list.ts
type Sizes = {
  get(index: number): number | undefined;
  set(index: number, size: number): void;
  readonly size: number;
};

type Range = { start: number; end: number };

type MeasuredListOptions = {
  count: number;
  estimateSize: number;
  overscan: number;
  sizes: Sizes;
};

建立清單的參數與昨天大致相同:count 是總列數,overscan 是視窗上下各多畫幾列。Range 是要畫的範圍,start 含、end 不含。原本固定的列高換成估計值 estimateSize,再多傳一個 sizes。

每一列不再一樣高,所以先要有 sizeOf,查某一列多高:記過就用記下的,沒記過才用估計值。第 index 列的上緣 offsetOf 也不能再用固定列高乘上索引算,而是把它前面的列高加起來:

// measured-list.ts
export function createMeasuredList({
  count,
  estimateSize,
  overscan,
  sizes,
}: MeasuredListOptions) {
  const sizeOf = (index: number): number => sizes.get(index) ?? estimateSize;

  const offsetOf = (index: number): number => {
    let offset = 0;
    for (let i = 0; i < index; i += 1) offset += sizeOf(i);
    return offset;
  };

  const range = (scrollTop: number, viewportHeight: number): Range => {
    let first = 0;
    while (first < count - 1 && offsetOf(first + 1) <= scrollTop) first += 1;
    let last = first;
    while (last < count && offsetOf(last) < scrollTop + viewportHeight)
      last += 1;
    return {
      start: Math.max(0, first - overscan),
      end: Math.min(count, last + overscan),
    };
  };

  return {
    range,
    sizeOf,
    offsetOf,
    totalSize: (): number => offsetOf(count),
    measure: (index: number, size: number): void => {
      sizes.set(index, size);
    },
  };
}

range 的工作和昨天一樣,找出碰到視窗的列,再往上下各多加 overscan 列。只是這次必須逐列比對位置。

totalSize 是整份清單的總高度,用來撐開捲動容器,現在等於最後一列下緣的位置。measure 是留給瀏覽器那一側的入口,量到多少,就記下多少。

接著要決定,誰來量。

瀏覽器提供了 ResizeObserver API:把元素交給它觀察,元素的尺寸一有變化,它就會在下一次畫面繪製之前回呼,告訴我們新的尺寸。這裡的列掛上 DOM 並開始被觀察後,也會收到初次回報。

回呼收到的是一串 entries,每一筆對應一個尺寸變了的元素:entry.target 是那個元素,entry.borderBoxSize[0].blockSize 是它連同內距與邊框在內的高度。

先準備資料與清單。沿用昨天的設定,視窗高 200px,每一列上下各有 12px 內距、行高 26px,所以一行字的列是 12 + 26 + 12 = 50px。索引為 3 的列文字比較長,在這次實驗的字型與寬度下,會折成三行。

// List.tsx
import { useCallback, useReducer, useState } from "react";
import { createMeasuredList } from "./measured-list";

const VIEWPORT_HEIGHT = 200;
const rowStyle = { padding: "12px 16px", lineHeight: "26px" };

const rows = Array.from({ length: 20 }, (_, i) =>
  i === 3
    ? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
    : `第 ${i} 列`,
);
const sizes = new Map<number, number>();
const list = createMeasuredList({
  count: rows.length,
  estimateSize: 40,
  overscan: 1,
  sizes,
});

然後在元件裡建立 ResizeObserver。一般的重新渲染不需要換一個 observer,所以把它放進 useState 的初始化函式,只在首次渲染時建立。回呼從每一列的 data-index 認出是第幾列,把量到的高度交給 measure。

記下的尺寸存在元件之外的 sizes,React 不知道它變了,所以我們另外用 useReducer 做一個 rerender,它唯一的用途是觸發重新渲染:

// List.tsx
export const List = () => {
  const [scrollTop, setScrollTop] = useState(0);
  const [, rerender] = useReducer((n: number) => n + 1, 0);
  const [observer] = useState(
    () =>
      new ResizeObserver((entries) => {
        for (const entry of entries) {
          const row = entry.target as HTMLElement;
          const index = Number(row.dataset.index);
          const size = entry.borderBoxSize[0]?.blockSize;
          if (size === undefined) continue;
          console.log(`量到第 ${index} 列:${size}px`);
          list.measure(index, size);
        }
        rerender();
      }),
  );

  const observe = useCallback(
    (row: HTMLDivElement) => {
      observer.observe(row, { box: "border-box" });
      return () => observer.unobserve(row);
    },
    [observer],
  );

  const { start, end } = list.range(scrollTop, VIEWPORT_HEIGHT);
  console.log(`畫出第 ${start}–${end - 1} 列,記下 ${sizes.size} 筆`);

  return (
    <div
      style={{ width: 320, height: VIEWPORT_HEIGHT, overflowY: "auto" }}
      onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
    >
      <div style={{ position: "relative", height: list.totalSize() }}>
        {rows.slice(start, end).map((text, i) => {
          const index = start + i;
          return (
            <div
              key={index}
              ref={observe}
              data-index={index}
              style={{
                ...rowStyle,
                position: "absolute",
                top: list.offsetOf(index),
                left: 0,
                right: 0,
              }}
            >
              {text}
            </div>
          );
        })}
      </div>
    </div>
  );
};

每一列掛上 DOM 時,ref={observe} 把它交給 observer;卸下時,回傳的清理函式取消觀察。這裡使用的是 React 19 支援的 ref 清理函式。

useCallback 讓一般的重新渲染維持同一個 ref 函式,避免因函式身分改變,把原本正在觀察的列取消再重新觀察。

打開頁面,主控台印出:

畫出第 0–5 列,記下 0 筆
量到第 0 列:50px
量到第 1 列:50px
量到第 2 列:50px
量到第 3 列:102px
量到第 4 列:50px
量到第 5 列:50px
畫出第 0–4 列,記下 6 筆

第一次渲染時,每一列都假設是 40px,200px 的視窗裝得下 5 列,再加上下方一列過掃描,所以畫出第 0–5 列。

這 6 列開始被觀察後,量到了各自的高度。第 3 列是 102px,第 4 列的上緣因此變成 3 × 50 + 102 = 252px。重新計算時,第 0–3 列已經填滿視窗,再加一列過掃描,畫出的是第 0–4 列。

第 4 列接在第 3 列的正下方,昨天的重疊不見了。

💡 這是為了看清楚量測與儲存的最小實驗。offsetOf 每次從頂端加總,range 又反覆呼叫它,並不適合直接拿來處理大量資料。模組層的 sizes 與 list 也只是方便觀察,並未隔離同一元件的多個實例。正式實作還要處理生命週期、隱藏元素、排列方向與捲動效能等問題。

再慢慢往下捲到底,每一列進到畫面時都被量一次,主控台最後一行是:

畫出第 15–19 列,記下 20 筆

位置都對了。可是這二十筆裡,有十九筆記的是同一個數字:50。

這個 Map 替每一列都留了一格,不要求任何兩列一樣高。如果資料經常是相鄰幾列一樣高,是否有別的記法?

先把這個問題放一下。我們還有另一件事要比較:一定得看著每一列嗎?

只看著裝列的那個容器

每一列各自被看著,回報的就是那一列自己;另一種作法反過來,不看每一列,只看裝著它們的容器。

要這樣做,容器得在某一列變高時跟著變高。昨天的 PlainList 已經讓我們看過:列留在正常的文件流裡,第 3 列變高,就會把後面的列往下推,裝著它們的元素也跟著長高。絕對定位的列不占文件流的空間,做不到這件事。

所以這一版不用絕對定位。畫出來的列照順序排在一個容器裡,容器上方墊一段 paddingTop,高度是第一個被畫出來的列之前所有列的總和,讓第 start 列落在它該在的位置。

但 observer 只會告訴我們「容器尺寸變了」,不會說是哪一列。我們得走訪容器裡的列,自己讀它們的 offsetHeight,再判斷哪些和目前記下的尺寸不同。

那麼,比對的值從哪裡拿?

直覺上可以回頭查 sizes。不過 react-virtuoso 的作法不太一樣:畫一列時,把這次渲染採用的尺寸寫進 DOM 屬性 data-known-size。

// react-virtuoso: src/Virtuoso.tsx
data-known-size={item.size}

量的時候,getChangedChildSizes 拿到的是容器的子元素 children。沒有 data-index 的不是列,跳過;讀到的尺寸與 data-known-size 一樣,也跳過:

// react-virtuoso: src/hooks/useChangedChildSizes.ts
for (let i = 0; i < length; i++) {
  const child = children.item(i) as HTMLElement

  if (child.dataset.index === undefined) {
    continue
  }

  const index = parseInt(child.dataset.index, 10)
  const knownSize = parseFloat(child.dataset.knownSize!)
  const size = itemSize(child, field)

  // ⋯⋯尺寸為 0 時,記一筆錯誤

  if (size === knownSize) {
    continue
  }

  // ⋯⋯尺寸變了的列,收進 results
}

itemSize(child, field) 讀的是捲動方向上的尺寸;本文的直向清單使用高度。offsetHeight 是整數,因此它和前一版讀到的 border box 尺寸,在有小數尺寸時不一定完全相同。

這個函式拿到的只有容器的 children。哪一列是第幾列,讀 data-index;上次記下多少,讀 data-known-size。尺寸實際存在哪裡,它不必知道。

尺寸仍由清單那一側保管。DOM 上放的是供量測比對的值,像貼在元素身上的備忘錄。

我們照這個形狀寫一版 ContainerList。設定與上一版相同,只建立另一個 Map 與 list,讓兩份實驗各記各的。scrollTop、rerender 與外層捲動容器也沿用上一版。

換掉的第一部分是 observer 回呼:

// ContainerList.tsx:元件內
const [observer] = useState(
  () =>
    new ResizeObserver((entries) => {
      const container = entries[0]?.target;
      if (!(container instanceof HTMLElement)) return;

      let reported = 0;
      for (const child of container.children) {
        const row = child as HTMLElement;
        const size = row.offsetHeight;
        if (size === Number(row.dataset.knownSize)) continue;
        list.measure(Number(row.dataset.index), size);
        reported += 1;
      }
      console.log(`讀了 ${container.children.length} 列,回報 ${reported} 列`);
      if (reported > 0) rerender();
    }),
);

const observe = useCallback(
  (container: HTMLDivElement) => {
    observer.observe(container, { box: "border-box" });
    return () => observer.unobserve(container);
  },
  [observer],
);

第二部分是內層 JSX:列不再絕對定位,ref 移到容器,每一列加上 data-known-size。

// ContainerList.tsx:外層捲動容器內
<div style={{ height: list.totalSize() }}>
  <div ref={observe} style={{ paddingTop: list.offsetOf(start) }}>
    {rows.slice(start, end).map((text, i) => {
      const index = start + i;
      return (
        <div
          key={index}
          data-index={index}
          data-known-size={list.sizeOf(index)}
          style={rowStyle}
        >
          {text}
        </div>
      );
    })}
  </div>
</div>

為什麼觀察容器時指定 box: "border-box"?

假設畫出的 7 列都是 50px。往下捲一列,上方一列離開、下方一列進來,內容高度仍是 350px。若只觀察 content box,這次替換不會因內容高度而觸發回呼。

但 paddingTop 多了 50px。把內距也算進去的 border box 會變高,就能讓這份實驗重新讀取子列尺寸。

這不代表觀察容器就能捕捉每一次子列變化。如果一列變高、另一列同時變矮,總高度恰好不變,容器的尺寸可能沒有變化,也就不能只靠這個通知知道發生了什麼。

打開頁面:

畫出第 0–5 列,記下 0 筆
讀了 6 列,回報 6 列
畫出第 0–4 列,記下 6 筆
讀了 5 列,回報 0 列

第一次回呼時,6 列的 data-known-size 都是估計值 40,讀到的都不是 40,所以全部回報。重新渲染後,第 5 列不再畫出,容器變矮,回呼又來一次。這次每一列的比對值都已更新,一列都沒有回報,也不需要再觸發渲染。

眼前這次循環停在這裡。但我們只看到了這份資料的結果,還沒有證明所有量測與渲染都一定會如此收斂。

往下捲時,每次回呼讀的是當下畫出的所有列,回報的通常只有新進畫面的那一兩列:

讀了 7 列,回報 1 列
讀了 7 列,回報 2 列

兩種作法量到的數字相同,差別在代價落在哪裡:

逐列觀察,被觀察的元素是每個畫出來的列;只觀察容器,被觀察的只有一個容器,但每次回呼都要把畫出來的每一列都讀一次 offsetHeight。

探索共享狀態時,我們問過通知該切得多細;這一次,粒度的代價落在 DOM 上:觀察多少個元素,以及每次通知後要讀取多少列的版面資訊。

捲到底之後,這一版也記下了 20 筆,其中十九筆是 50。被觀察的換了,尺寸仍然一列記一筆。

接下來換的是儲存方式。

把相鄰同高的列併成一段

量測這一層只負責把數字交給 list.measure,不必知道尺寸資料的內部結構。所以觀察方式可以保留,儲存方式單獨換掉。

回頭看 getChangedChildSizes 剛才略過的那一段,也就是尺寸變了的列,怎麼收進 results:

// react-virtuoso: src/hooks/useChangedChildSizes.ts
const lastResult = results[results.length - 1]
if (results.length === 0 || lastResult!.size !== size || lastResult!.endIndex !== index - 1) {
  results.push({ endIndex: index, size, startIndex: index })
} else {
  results[results.length - 1]!.endIndex++
}

每一筆是一段 SizeRange,記著從第幾列到第幾列,都是多高。這一列若與上一段同高,又緊接在它後面,就把上一段延長;否則另起一段。

所以回報不一定一列一筆:相鄰且同高的列,會被併成一段。react-virtuoso 的尺寸結構也能表示這樣的連續區間。

讓我們寫一個以段為單位的 sizes。這裡的 startIndex 與 endIndex 都包含在段內,和前面渲染範圍的 end 不同。

// range-sizes.ts
export type SizeRange = {
  startIndex: number;
  endIndex: number;
  size: number;
};

寫入一列時分兩步。第一步是拆:走訪每一段,沒有包含 index 的段原樣留下;包含它的段,切掉 index 那一列,前後剩下的部分各自成段。最後放進新寫入的這一列,依起點排好:

// range-sizes.ts
export const withSize = (
  ranges: readonly SizeRange[],
  index: number,
  size: number,
): SizeRange[] => {
  const pieces: SizeRange[] = [];
  for (const r of ranges) {
    if (index < r.startIndex || r.endIndex < index) {
      pieces.push(r);
      continue;
    }
    if (r.startIndex < index)
      pieces.push({
        startIndex: r.startIndex,
        endIndex: index - 1,
        size: r.size,
      });
    if (index < r.endIndex)
      pieces.push({
        startIndex: index + 1,
        endIndex: r.endIndex,
        size: r.size,
      });
  }
  pieces.push({ startIndex: index, endIndex: index, size });
  pieces.sort((a, b) => a.startIndex - b.startIndex);

第二步是併:依序走過每一段,若與前一段首尾相接、而且同高,就把前一段延長;否則另起一段。這和 getChangedChildSizes 收 results 的判斷是同一件事:

// range-sizes.ts
  const merged: SizeRange[] = [];
  for (const piece of pieces) {
    const last = merged[merged.length - 1];
    if (
      last &&
      last.endIndex + 1 === piece.startIndex &&
      last.size === piece.size
    )
      merged[merged.length - 1] = { ...last, endIndex: piece.endIndex };
    else merged.push(piece);
  }
  return merged;
};

再把它包成與 Map 相同的形狀:get、set,以及代表記了幾筆的 size,讓 createMeasuredList 能沿用相同的入口:

// range-sizes.ts
export function createRangeSizes() {
  let ranges: SizeRange[] = [];
  return {
    get: (index: number): number | undefined =>
      ranges.find((r) => r.startIndex <= index && index <= r.endIndex)?.size,
    set: (index: number, size: number): void => {
      ranges = withSize(ranges, index, size);
    },
    get size(): number {
      return ranges.length;
    },
  };
}

兩個元件各自匯入 createRangeSizes,再把建立 sizes 的那一行換掉:

// List.tsx、ContainerList.tsx
const sizes = createRangeSizes();

位置計算依賴 get,量測寫入依賴 set,所以不用跟著換。只有 log 裡的 size 換了意思:原本數的是量過幾列,現在數的是存了幾段。

同樣慢慢捲到底,主控台的最後一行變成:

畫出第 15–19 列,記下 3 筆

三段分別是:第 0–2 列 50px、第 3 列 102px、第 4–19 列 50px。

同樣的二十列,Map 記了 20 筆,段只記了 3 筆。多出來的那 17 筆,記的都是「這一列和旁邊的一樣高」。

接著試試看,之後資料變了,兩邊會怎麼樣。把 rows 改成在元件裡由狀態產生,建立清單時的 count 固定為 20,再加一顆切換按鈕:

// List.tsx、ContainerList.tsx
// 模組層:取代原本的 rows
const rowsOf = (long: boolean) =>
  Array.from({ length: 20 }, (_, i) =>
    i === 3 && long
      ? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
      : `第 ${i} 列`,
  );

// 元件裡
const [long, setLong] = useState(true);
const rows = rowsOf(long);

// 放在捲動容器前,兩者用 Fragment 包起來
<button onClick={() => setLong((l) => !l)}>切換第 3 列</button>

量過所有列後,捲回頂端,按一下按鈕,第 3 列變回一行字、50px。

Map 把第 3 列那一格改成 50,還是 20 筆。段這邊,第 3 列一變成 50,就與前後兩段接成一片,三段併成一段:第 0–19 列 50px。

再換一份資料,讓列高依序重複 50px、76px、102px:

// List.tsx、ContainerList.tsx
// 模組層:取代原本的 rows
const rows = Array.from({ length: 20 }, (_, i) =>
  i % 3 === 0
    ? `第 ${i} 列`
    : i % 3 === 1
      ? `第 ${i} 列:這一列稍微長一點,會折成兩行。`
      : `第 ${i} 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。`,
);

相鄰列都不同高,捲到底後就是一列一段,區間版也記了 20 段。

第三份資料也有規律,只是這種規律不能靠「相鄰同高」壓縮。

兩種記法

同一份量測結果,存成兩種形狀,記下的數量可以差很多。差別不在量到了什麼,而在相鄰列的尺寸像不像。

怎麼存,反映了實作預期資料會呈現什麼形狀,也決定某一列變高時,要付出多少更新成本。

一列一筆的 Map,不要求相鄰列有共同尺寸。在我們這份每次量到都寫入的版本裡,記多少取決於量過幾列;某一列之後變了,也只更新那一格尺寸。

一段一筆的區間,押的是相鄰列經常一樣高,能連成較長的區段。記多少,取決於這些列形成幾段;相鄰尺寸切換得越頻繁,段就越多。

押中的時候,第 3 列一變回 50px,三段就收成一段;押錯的時候,區間退化成一列一段,寫入仍然得維護段的拆分與合併。我們這份簡化實作甚至每次都重新走訪、排序,沒有把更新成本藏掉。

原作在這兩個方向上,又多做了一些事。

本文引用的 TanStack Virtual 實作以每一列的 key 為索引,把尺寸存在 itemSizeCache 裡,並在量到的尺寸與目前採用的尺寸不同時才更新。第一次量到的值若正好等於估計值,就可能不必新增一筆尺寸紀錄。呼叫端透過 estimateSize 提供的預估,也會影響這份資料如何累積。

react-virtuoso 則把尺寸的區間邊界存在 sizeTree 裡。每個節點記著「從第幾列開始,是多高」,直到下一個邊界為止。在本文引用的初始化路徑中,尺寸還沒有任何紀錄時,會把首次回報中第一段的尺寸寫到索引 0:

// react-virtuoso: src/sizeSystem.ts
sizeTree = insert(sizeTree, 0, size)

這等於先替尚未量過的列採用一個共同尺寸,再用後續量到的結果修正。和我們只合併已量測區間的版本相比,它還把這個預期延伸到沒畫過的列。

一邊讓呼叫端提供每一列的估計;另一邊能從首次量到的結果建立共同尺寸。兩邊都得面對未知,只是取得起點的方式不同。

不管尺寸怎麼記,量到不一樣的那一刻,位置都得重新算。剛才把第 3 列切回長的,它從 50px 變成 102px,下面每一列的計算位置都往下移了 52px。

如果之後重新量到視窗上方的一列,才發現它比原先記下的更高,而你正在讀第 10 列呢?

列的位置算對了,你正在讀的內容,還能留在原來的地方嗎?

文章所用的完整程式碼:Notion

原作的出處:react-virtuoso 的 useChangedChildSizes.ts(getChangedChildSizes)、Virtuoso.tsx(data-known-size)、sizeSystem.ts(insertRanges);TanStack Virtual 的 virtual-core/src/index.ts(itemSizeCache、resizeItem)。取自預設分支原始碼,npm 最新版為 react-virtuoso 4.18.16、@tanstack/virtual-core 3.17.11,查核於 2026-10-08。

本文的實驗跑在 react 19.3.0 與 react-dom 19.3.0,Chrome,字型 PingFang TC 16px,查核於 2026-10-09。


上一篇
【 Day 23 】為什麼我剛畫出來的東西,會讓我剛算好的位置變成錯的?|真實 DOM(一)
下一篇
【 Day 25 】量到不一樣之後,捲動位置該不該補償?|真實 DOM(三)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言